/*
 * Copyright 2017 - 2024 the original author or authors.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see [https://www.gnu.org/licenses/]
 */

package infra.beans.factory.xml;

import org.w3c.dom.Document;
import org.xml.sax.EntityResolver;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import javax.xml.parsers.ParserConfigurationException;

import infra.beans.BeanUtils;
import infra.beans.factory.BeanDefinitionStoreException;
import infra.beans.factory.parsing.EmptyReaderEventListener;
import infra.beans.factory.parsing.FailFastProblemReporter;
import infra.beans.factory.parsing.NullSourceExtractor;
import infra.beans.factory.parsing.ProblemReporter;
import infra.beans.factory.parsing.ReaderEventListener;
import infra.beans.factory.parsing.SourceExtractor;
import infra.beans.factory.support.AbstractBeanDefinitionReader;
import infra.beans.factory.support.BeanDefinitionRegistry;
import infra.beans.factory.support.StandardBeanFactory;
import infra.core.NamedThreadLocal;
import infra.core.io.DescriptiveResource;
import infra.core.io.EncodedResource;
import infra.core.io.Resource;
import infra.core.io.ResourceLoader;
import infra.lang.Assert;
import infra.lang.Nullable;
import infra.util.xml.SimpleSaxErrorHandler;
import infra.util.xml.XmlValidationModeDetector;

/**
 * Bean definition reader for XML bean definitions.
 * Delegates the actual XML document reading to an implementation
 * of the {@link BeanDefinitionDocumentReader} interface.
 *
 * <p>Typically applied to a
 * {@link StandardBeanFactory}
 * or a {@link infra.context.support.GenericApplicationContext}.
 *
 * <p>This class loads a DOM document and applies the BeanDefinitionDocumentReader to it.
 * The document reader will register each bean definition with the given bean factory,
 * talking to the latter's implementation of the
 * {@link BeanDefinitionRegistry} interface.
 *
 * @author Juergen Hoeller
 * @author Rob Harrop
 * @author Chris Beams
 * @author <a href="https://github.com/TAKETODAY">Harry Yang</a>
 * @see #setDocumentReaderClass
 * @see BeanDefinitionDocumentReader
 * @see DefaultBeanDefinitionDocumentReader
 * @see BeanDefinitionRegistry
 * @see StandardBeanFactory
 * @see infra.context.support.GenericApplicationContext
 * @since 4.0 2022/3/6 22:04
 */
public class XmlBeanDefinitionReader extends AbstractBeanDefinitionReader {

  /**
   * Indicates that the validation should be disabled.
   */
  public static final int VALIDATION_NONE = XmlValidationModeDetector.VALIDATION_NONE;

  /**
   * Indicates that the validation mode should be detected automatically.
   */
  public static final int VALIDATION_AUTO = XmlValidationModeDetector.VALIDATION_AUTO;

  /**
   * Indicates that DTD validation should be used.
   */
  public static final int VALIDATION_DTD = XmlValidationModeDetector.VALIDATION_DTD;

  /**
   * Indicates that XSD validation should be used.
   */
  public static final int VALIDATION_XSD = XmlValidationModeDetector.VALIDATION_XSD;

  /**
   * Map of constant names to constant values for the validation constants defined
   * in this class.
   */
  private static final Map<String, Integer> constants = Map.of(
          "VALIDATION_NONE", VALIDATION_NONE,
          "VALIDATION_AUTO", VALIDATION_AUTO,
          "VALIDATION_DTD", VALIDATION_DTD,
          "VALIDATION_XSD", VALIDATION_XSD
  );

  private int validationMode = VALIDATION_AUTO;

  private boolean namespaceAware = false;

  private Class<? extends BeanDefinitionDocumentReader> documentReaderClass =
          DefaultBeanDefinitionDocumentReader.class;

  private ProblemReporter problemReporter = new FailFastProblemReporter();

  private ReaderEventListener eventListener = new EmptyReaderEventListener();

  private SourceExtractor sourceExtractor = new NullSourceExtractor();

  @Nullable
  private NamespaceHandlerResolver namespaceHandlerResolver;

  private DocumentLoader documentLoader = new DefaultDocumentLoader();

  @Nullable
  private EntityResolver entityResolver;

  private ErrorHandler errorHandler = new SimpleSaxErrorHandler(logger);

  private final XmlValidationModeDetector validationModeDetector = new XmlValidationModeDetector();

  private final ThreadLocal<Set<EncodedResource>> resourcesCurrentlyBeingLoaded =
          NamedThreadLocal.withInitial("XML bean definition resources currently being loaded", () -> new HashSet<>(4));

  /**
   * Create new XmlBeanDefinitionReader for the given bean factory.
   *
   * @param registry the BeanFactory to load bean definitions into,
   * in the form of a BeanDefinitionRegistry
   */
  public XmlBeanDefinitionReader(BeanDefinitionRegistry registry) {
    super(registry);
  }

  /**
   * Set whether to use XML validation. Default is {@code true}.
   * <p>This method switches namespace awareness on if validation is turned off,
   * in order to still process schema namespaces properly in such a scenario.
   *
   * @see #setValidationMode
   * @see #setNamespaceAware
   */
  public void setValidating(boolean validating) {
    this.validationMode = (validating ? VALIDATION_AUTO : VALIDATION_NONE);
    this.namespaceAware = !validating;
  }

  /**
   * Set the validation mode to use by name. Defaults to {@link #VALIDATION_AUTO}.
   *
   * @see #setValidationMode
   */
  public void setValidationModeName(String validationModeName) {
    Assert.hasText(validationModeName, "'validationModeName' must not be null or blank");
    Integer validationMode = constants.get(validationModeName);
    Assert.notNull(validationMode, "Only validation mode constants allowed");
    this.validationMode = validationMode;
  }

  /**
   * Set the validation mode to use. Defaults to {@link #VALIDATION_AUTO}.
   * <p>Note that this only activates or deactivates validation itself.
   * If you are switching validation off for schema files, you might need to
   * activate schema namespace support explicitly: see {@link #setNamespaceAware}.
   */
  public void setValidationMode(int validationMode) {
    Assert.isTrue(constants.containsValue(validationMode),
            "Only values of validation mode constants allowed");
    this.validationMode = validationMode;
  }

  /**
   * Return the validation mode to use.
   */
  public int getValidationMode() {
    return this.validationMode;
  }

  /**
   * Set whether or not the XML parser should be XML namespace aware.
   * Default is "false".
   * <p>This is typically not needed when schema validation is active.
   * However, without validation, this has to be switched to "true"
   * in order to properly process schema namespaces.
   */
  public void setNamespaceAware(boolean namespaceAware) {
    this.namespaceAware = namespaceAware;
  }

  /**
   * Return whether or not the XML parser should be XML namespace aware.
   */
  public boolean isNamespaceAware() {
    return this.namespaceAware;
  }

  /**
   * Specify which {@link ProblemReporter} to use.
   * <p>The default implementation is {@link FailFastProblemReporter}
   * which exhibits fail fast behaviour. External tools can provide an alternative implementation
   * that collates errors and warnings for display in the tool UI.
   */
  public void setProblemReporter(@Nullable ProblemReporter problemReporter) {
    this.problemReporter = (problemReporter != null ? problemReporter : new FailFastProblemReporter());
  }

  /**
   * Specify which {@link ReaderEventListener} to use.
   * <p>The default implementation is EmptyReaderEventListener which discards every event notification.
   * External tools can provide an alternative implementation to monitor the components being
   * registered in the BeanFactory.
   */
  public void setEventListener(@Nullable ReaderEventListener eventListener) {
    this.eventListener = (eventListener != null ? eventListener : new EmptyReaderEventListener());
  }

  /**
   * Specify the {@link SourceExtractor} to use.
   * <p>The default implementation is {@link NullSourceExtractor} which simply returns {@code null}
   * as the source object. This means that - during normal runtime execution -
   * no additional source metadata is attached to the bean configuration metadata.
   */
  public void setSourceExtractor(@Nullable SourceExtractor sourceExtractor) {
    this.sourceExtractor = (sourceExtractor != null ? sourceExtractor : new NullSourceExtractor());
  }

  /**
   * Specify the {@link NamespaceHandlerResolver} to use.
   * <p>If none is specified, a default instance will be created through
   * {@link #createDefaultNamespaceHandlerResolver()}.
   */
  public void setNamespaceHandlerResolver(@Nullable NamespaceHandlerResolver namespaceHandlerResolver) {
    this.namespaceHandlerResolver = namespaceHandlerResolver;
  }

  /**
   * Specify the {@link DocumentLoader} to use.
   * <p>The default implementation is {@link DefaultDocumentLoader}
   * which loads {@link Document} instances using JAXP.
   */
  public void setDocumentLoader(@Nullable DocumentLoader documentLoader) {
    this.documentLoader = (documentLoader != null ? documentLoader : new DefaultDocumentLoader());
  }

  /**
   * Set a SAX entity resolver to be used for parsing.
   * <p>By default, {@link ResourceEntityResolver} will be used. Can be overridden
   * for custom entity resolution, for example relative to some specific base path.
   */
  public void setEntityResolver(@Nullable EntityResolver entityResolver) {
    this.entityResolver = entityResolver;
  }

  /**
   * Return the EntityResolver to use, building a default resolver
   * if none specified.
   */
  protected EntityResolver getEntityResolver() {
    if (this.entityResolver == null) {
      // Determine default EntityResolver to use.
      ResourceLoader resourceLoader = getResourceLoader();
      if (resourceLoader != null) {
        this.entityResolver = new ResourceEntityResolver(resourceLoader);
      }
      else {
        this.entityResolver = new DelegatingEntityResolver(getBeanClassLoader());
      }
    }
    return this.entityResolver;
  }

  /**
   * Set an implementation of the {@code org.xml.sax.ErrorHandler}
   * interface for custom handling of XML parsing errors and warnings.
   * <p>If not set, a default SimpleSaxErrorHandler is used that simply
   * logs warnings using the logger instance of the view class,
   * and rethrows errors to discontinue the XML transformation.
   *
   * @see SimpleSaxErrorHandler
   */
  public void setErrorHandler(ErrorHandler errorHandler) {
    this.errorHandler = errorHandler;
  }

  /**
   * Specify the {@link BeanDefinitionDocumentReader} implementation to use,
   * responsible for the actual reading of the XML bean definition document.
   * <p>The default is {@link DefaultBeanDefinitionDocumentReader}.
   *
   * @param documentReaderClass the desired BeanDefinitionDocumentReader implementation class
   */
  public void setDocumentReaderClass(Class<? extends BeanDefinitionDocumentReader> documentReaderClass) {
    this.documentReaderClass = documentReaderClass;
  }

  /**
   * Load bean definitions from the specified XML file.
   *
   * @param resource the resource descriptor for the XML file
   * @return the number of bean definitions found
   * @throws BeanDefinitionStoreException in case of loading or parsing errors
   */
  @Override
  public int loadBeanDefinitions(Resource resource) throws BeanDefinitionStoreException {
    return loadBeanDefinitions(new EncodedResource(resource));
  }

  /**
   * Load bean definitions from the specified XML file.
   *
   * @param encodedResource the resource descriptor for the XML file,
   * allowing to specify an encoding to use for parsing the file
   * @return the number of bean definitions found
   * @throws BeanDefinitionStoreException in case of loading or parsing errors
   */
  public int loadBeanDefinitions(EncodedResource encodedResource) throws BeanDefinitionStoreException {
    Assert.notNull(encodedResource, "EncodedResource is required");
    if (logger.isTraceEnabled()) {
      logger.trace("Loading XML bean definitions from {}", encodedResource);
    }

    Set<EncodedResource> currentResources = this.resourcesCurrentlyBeingLoaded.get();

    if (!currentResources.add(encodedResource)) {
      throw new BeanDefinitionStoreException(
              "Detected cyclic loading of " + encodedResource + " - check your import definitions!");
    }

    try (InputStream inputStream = encodedResource.getResource().getInputStream()) {
      InputSource inputSource = new InputSource(inputStream);
      if (encodedResource.getEncoding() != null) {
        inputSource.setEncoding(encodedResource.getEncoding());
      }
      return doLoadBeanDefinitions(inputSource, encodedResource.getResource());
    }
    catch (IOException ex) {
      throw new BeanDefinitionStoreException(
              "IOException parsing XML document from " + encodedResource.getResource(), ex);
    }
    finally {
      currentResources.remove(encodedResource);
      if (currentResources.isEmpty()) {
        this.resourcesCurrentlyBeingLoaded.remove();
      }
    }
  }

  /**
   * Load bean definitions from the specified XML file.
   *
   * @param inputSource the SAX InputSource to read from
   * @return the number of bean definitions found
   * @throws BeanDefinitionStoreException in case of loading or parsing errors
   */
  public int loadBeanDefinitions(InputSource inputSource) throws BeanDefinitionStoreException {
    return loadBeanDefinitions(inputSource, "resource loaded through SAX InputSource");
  }

  /**
   * Load bean definitions from the specified XML file.
   *
   * @param inputSource the SAX InputSource to read from
   * @param resourceDescription a description of the resource
   * (can be {@code null} or empty)
   * @return the number of bean definitions found
   * @throws BeanDefinitionStoreException in case of loading or parsing errors
   */
  public int loadBeanDefinitions(InputSource inputSource, @Nullable String resourceDescription)
          throws BeanDefinitionStoreException {

    return doLoadBeanDefinitions(inputSource, new DescriptiveResource(resourceDescription));
  }

  /**
   * Actually load bean definitions from the specified XML file.
   *
   * @param inputSource the SAX InputSource to read from
   * @param resource the resource descriptor for the XML file
   * @return the number of bean definitions found
   * @throws BeanDefinitionStoreException in case of loading or parsing errors
   * @see #doLoadDocument
   * @see #registerBeanDefinitions
   */
  protected int doLoadBeanDefinitions(InputSource inputSource, Resource resource)
          throws BeanDefinitionStoreException {

    try {
      Document doc = doLoadDocument(inputSource, resource);
      int count = registerBeanDefinitions(doc, resource);
      if (logger.isDebugEnabled()) {
        logger.debug("Loaded {} bean definitions from {}", count, resource);
      }
      return count;
    }
    catch (BeanDefinitionStoreException ex) {
      throw ex;
    }
    catch (SAXParseException ex) {
      throw new XmlBeanDefinitionStoreException(resource.toString(),
              "Line " + ex.getLineNumber() + " in XML document from " + resource + " is invalid", ex);
    }
    catch (SAXException ex) {
      throw new XmlBeanDefinitionStoreException(resource.toString(),
              "XML document from " + resource + " is invalid", ex);
    }
    catch (ParserConfigurationException ex) {
      throw new BeanDefinitionStoreException(resource.toString(),
              "Parser configuration exception parsing XML from " + resource, ex);
    }
    catch (IOException ex) {
      throw new BeanDefinitionStoreException(resource.toString(),
              "IOException parsing XML document from " + resource, ex);
    }
    catch (Throwable ex) {
      throw new BeanDefinitionStoreException(resource.toString(),
              "Unexpected exception parsing XML document from " + resource, ex);
    }
  }

  /**
   * Actually load the specified document using the configured DocumentLoader.
   *
   * @param inputSource the SAX InputSource to read from
   * @param resource the resource descriptor for the XML file
   * @return the DOM Document
   * @throws Exception when thrown from the DocumentLoader
   * @see #setDocumentLoader
   * @see DocumentLoader#loadDocument
   */
  protected Document doLoadDocument(InputSource inputSource, Resource resource) throws Exception {
    return this.documentLoader.loadDocument(inputSource, getEntityResolver(), this.errorHandler,
            getValidationModeForResource(resource), isNamespaceAware());
  }

  /**
   * Determine the validation mode for the specified {@link Resource}.
   * If no explicit validation mode has been configured, then the validation
   * mode gets {@link #detectValidationMode detected} from the given resource.
   * <p>Override this method if you would like full control over the validation
   * mode, even when something other than {@link #VALIDATION_AUTO} was set.
   *
   * @see #detectValidationMode
   */
  protected int getValidationModeForResource(Resource resource) {
    int validationModeToUse = getValidationMode();
    if (validationModeToUse != VALIDATION_AUTO) {
      return validationModeToUse;
    }
    int detectedMode = detectValidationMode(resource);
    if (detectedMode != VALIDATION_AUTO) {
      return detectedMode;
    }
    // Hmm, we didn't get a clear indication... Let's assume XSD,
    // since apparently no DTD declaration has been found up until
    // detection stopped (before finding the document's root tag).
    return VALIDATION_XSD;
  }

  /**
   * Detect which kind of validation to perform on the XML file identified
   * by the supplied {@link Resource}. If the file has a {@code DOCTYPE}
   * definition then DTD validation is used otherwise XSD validation is assumed.
   * <p>Override this method if you would like to customize resolution
   * of the {@link #VALIDATION_AUTO} mode.
   */
  protected int detectValidationMode(Resource resource) {
    if (resource.isOpen()) {
      throw new BeanDefinitionStoreException(
              "Passed-in Resource [" + resource + "] contains an open stream: " +
                      "cannot determine validation mode automatically. Either pass in a Resource " +
                      "that is able to create fresh streams, or explicitly specify the validationMode " +
                      "on your XmlBeanDefinitionReader instance.");
    }

    InputStream inputStream;
    try {
      inputStream = resource.getInputStream();
    }
    catch (IOException ex) {
      throw new BeanDefinitionStoreException(
              "Unable to determine validation mode for [" + resource + "]: cannot open InputStream. " +
                      "Did you attempt to load directly from a SAX InputSource without specifying the " +
                      "validationMode on your XmlBeanDefinitionReader instance?", ex);
    }

    try {
      return this.validationModeDetector.detectValidationMode(inputStream);
    }
    catch (IOException ex) {
      throw new BeanDefinitionStoreException("Unable to determine validation mode for [" +
              resource + "]: an error occurred whilst reading from the InputStream.", ex);
    }
  }

  /**
   * Register the bean definitions contained in the given DOM document.
   * Called by {@code loadBeanDefinitions}.
   * <p>Creates a new instance of the parser class and invokes
   * {@code registerBeanDefinitions} on it.
   *
   * @param doc the DOM document
   * @param resource the resource descriptor (for context information)
   * @return the number of bean definitions found
   * @throws BeanDefinitionStoreException in case of parsing errors
   * @see #loadBeanDefinitions
   * @see #setDocumentReaderClass
   * @see BeanDefinitionDocumentReader#registerBeanDefinitions
   */
  public int registerBeanDefinitions(Document doc, Resource resource) throws BeanDefinitionStoreException {
    BeanDefinitionDocumentReader documentReader = createBeanDefinitionDocumentReader();
    int countBefore = getRegistry().getBeanDefinitionCount();
    documentReader.registerBeanDefinitions(doc, createReaderContext(resource));
    return getRegistry().getBeanDefinitionCount() - countBefore;
  }

  /**
   * Create the {@link BeanDefinitionDocumentReader} to use for actually
   * reading bean definitions from an XML document.
   * <p>The default implementation instantiates the specified "documentReaderClass".
   *
   * @see #setDocumentReaderClass
   */
  protected BeanDefinitionDocumentReader createBeanDefinitionDocumentReader() {
    if (documentReaderClass == DefaultBeanDefinitionDocumentReader.class) {
      return new DefaultBeanDefinitionDocumentReader();
    }
    return BeanUtils.newInstance(this.documentReaderClass);
  }

  /**
   * Create the {@link XmlReaderContext} to pass over to the document reader.
   */
  public XmlReaderContext createReaderContext(Resource resource) {
    return new XmlReaderContext(resource, this.problemReporter, this.eventListener,
            this.sourceExtractor, this, getNamespaceHandlerResolver());
  }

  /**
   * Lazily create a default NamespaceHandlerResolver, if not set before.
   *
   * @see #createDefaultNamespaceHandlerResolver()
   */
  public NamespaceHandlerResolver getNamespaceHandlerResolver() {
    if (this.namespaceHandlerResolver == null) {
      this.namespaceHandlerResolver = createDefaultNamespaceHandlerResolver();
    }
    return this.namespaceHandlerResolver;
  }

  /**
   * Create the default implementation of {@link NamespaceHandlerResolver} used if none is specified.
   * <p>The default implementation returns an instance of {@link DefaultNamespaceHandlerResolver}.
   *
   * @see DefaultNamespaceHandlerResolver#DefaultNamespaceHandlerResolver(ClassLoader)
   */
  protected NamespaceHandlerResolver createDefaultNamespaceHandlerResolver() {
    ClassLoader cl = (getResourceLoader() != null ? getResourceLoader().getClassLoader() : getBeanClassLoader());
    return new DefaultNamespaceHandlerResolver(cl);
  }

}
